Skip to content

fix(json): bound json_encode depth so a cyclic value raises instead of segfaulting (#730) - #757

Merged
InauguralPhysicist merged 1 commit into
mainfrom
fix-730-json-encode-depth
Jul 28, 2026
Merged

fix(json): bound json_encode depth so a cyclic value raises instead of segfaulting (#730)#757
InauguralPhysicist merged 1 commit into
mainfrom
fix-730-json-encode-depth

Conversation

@InauguralPhysicist

Copy link
Copy Markdown
Collaborator

Closes #730

The asymmetry

The decoder has been bounded at JSON_MAX_DEPTH since #495. The encoder walked the Value graph with no counter and no cycle check — and a Value graph can contain cycles; the cycle collector exists because it can.

d is {"x": 1}
dict_set of [d, "self", d]
try:
    print of (json_encode of d)
catch e:
    print of f"caught {e}"

Pre-fix: Segmentation fault (core dumped), rc=139. The catch is useless — try/catch does nothing against a SIGSEGV. print already handled cycles, which is what made json_encode the surprise. append of [a, a] builds one by accident.

Both directions now share one constant (hoisted above the encoder rather than duplicated), so a document that decodes always re-encodes. Exceeding it raises a catchable EK_VALUE rather than emitting truncated JSON — silent truncation would be the exact silent-wrong-answer class this audit is closing.

Why a parameter, not a counter

The decoder tracks depth in g_json_depth, a field on the execution state. That is safe there because there is no early return between its ++ and --. This encoder signals failure by returning -1 up through every frame — precisely the shape where a counter gets skewed by one missed decrement. And a skewed counter fails the next call, not the one that broke it. A parameter cannot leak, and needed no new state field or bridge macro.

Reachability

Beyond json_encode itself: json_path, and — the serious one — shared_set. An HTTP handler storing a self-referential value took the whole server down.

eigs_json_encode now returns NULL on refusal and both ext_http.c callers check it. They previously strlen'd the result unconditionally, so returning NULL without touching them would have traded a stack overflow for a NULL deref. shared_incr needed a mutex unlock on its new early return.

Tests

Extends test_json_depth.eigs (3 → 9 checks), the file that already owns the decoder half:

  • cyclic dict raises
  • cyclic list (append of [a, a]) raises
  • over-deep non-cyclic (300) raises
  • CONTROL: a 151-deep value still encodes (exact length 303) and decodes back — without this a bound of 1 would pass every raise-check above
  • round-trip proving the two limits agree

Pre-fix the section exits 139 (SIGSEGV); post-fix 0.

Validation

  • Release 3268/3268
  • asan-http + detect_leaks=1: 3363/3363, leak tally 0
  • embed_stack_soak.sh (64 KiB rlimit): PASS
  • -fstack-usage: 80 B/frame → ~16 KiB above baseline at the limit, vs the decoder's 96 B/frame at the same depth

Follow-up filed separately

Profiling the stack cost turned up something bigger that is not addressed here: under a 64 KiB stack the process SIGSEGVs at ~40–60 source nesting levels, so PARSE_MAX_DEPTH (256) is unreachable there — the stack dies before the guard can fire. Filing separately rather than changing a limit inside a JSON PR.

🤖 Generated with Claude Code

…f segfaulting (#730)

The decoder has been bounded at JSON_MAX_DEPTH since #495; the encoder walked
the Value graph with no counter and no cycle check. A Value graph CAN contain
cycles — the cycle collector exists because it can — and building one takes two
lines (dict_set of [d, "self", d]) or happens by accident (append of [a, a]).
The walk then recursed until the C stack was gone.

The crash was uncatchable: try/catch does nothing against a SIGSEGV. print
already handled cycles, which is what made json_encode the surprise.

Both directions now share one constant, hoisted above the encoder, so a
document that decodes always re-encodes. Exceeding it raises a catchable
EK_VALUE error rather than emitting truncated JSON — a silent truncation here
would be the exact silent-wrong-answer class this audit is closing.

Depth rides the C stack as a parameter rather than a thread-local counter like
the decoder's g_json_depth. The decoder can use a counter safely because it has
no early return between its ++ and --; this encoder signals failure by
returning -1 through every frame, which is precisely the shape where a counter
gets skewed by a missed decrement — and a skewed counter fails the NEXT call,
not the one that broke it. A parameter cannot leak.

Reachability beyond json_encode itself: json_path, and shared_set — an HTTP
handler storing a self-referential value took the whole server down.
eigs_json_encode now returns NULL on refusal and both ext_http.c callers check
it; they previously strlen'd the result unconditionally, so returning NULL
without touching them would have traded a stack overflow for a NULL deref.
shared_incr needed a mutex unlock on its new early return.

Tests extend test_json_depth.eigs (3 -> 9 checks), the file that already owns
the decoder half: cyclic dict, cyclic list, over-deep non-cyclic, plus a
CONTROL that a 151-deep value still encodes and decodes back — without it a
bound of 1 would pass every raise-check — and a round-trip proving the two
limits agree. Pre-fix the section exits 139 (SIGSEGV); post-fix 0.

Measured with -fstack-usage: 80 B/frame, so 200 levels is ~16 KiB above
baseline, against the decoder's 96 B/frame at the same limit. Release
3268/3268, asan-http 3363/3363 leak tally 0, embed_stack_soak (64 KiB) PASS.

Closes #730

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens JSON encoding to prevent uncatchable process crashes (SIGSEGV) when json_encode is given cyclic or excessively deep values, aligning encoder behavior with the existing decoder depth bound and making failures catchable runtime errors.

Changes:

  • Add a shared JSON_MAX_DEPTH limit and thread depth as a parameter through the JSON encoder, raising EK_VALUE on excess depth (including cyclic graphs).
  • Propagate the encoder’s new “refuse to encode” behavior into HTTP shared-store write paths by handling eigs_json_encode() returning NULL.
  • Expand JSON depth tests to cover cyclic structures, over-deep encoding, and decode/encode agreement; update docs and changelog accordingly.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/builtins.c Adds depth-bounded JSON encoding with a shared limit and a single error helper; updates json_encode, json_path, and the C-string encoder entry point.
src/ext_http.c Handles eigs_json_encode() returning NULL to avoid crashes in shared_set/shared_incr.
tests/test_json_depth.eigs Extends coverage to cyclic values, over-deep encoding, and a control case under the limit.
tests/run_all_tests.sh Updates the JSON depth section’s reported check count and PASS/FAIL tallies (currently off by one vs asserts).
docs/BUILTINS.md Documents that json_encode/json_decode raise past the shared depth limit and that cycles are rejected.
CHANGELOG.md Records the security fix for #730 in Unreleased notes.
Comments suppressed due to low confidence (1)

tests/run_all_tests.sh:592

  • This FAIL path still increments by 9, but the JSON depth test now has 10 asserts. Update the FAIL tally to keep counts consistent with the PASS/TOTAL side.
    echo "  FAIL: json-depth (possible crash/regression)"; FAIL=$((FAIL + 9))

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/run_all_tests.sh
Comment on lines +586 to +590
echo "[JSON Depth / DoS guard] (9 checks)"
JD_OUTPUT=$(./eigenscript ../tests/test_json_depth.eigs 2>&1)
TOTAL=$((TOTAL + 3))
TOTAL=$((TOTAL + 9))
if echo "$JD_OUTPUT" | grep -q "All tests passed"; then
echo " PASS: deep-JSON guard + nested parsing"; PASS=$((PASS + 3))
echo " PASS: deep-JSON guard (decode + encode) + nested parsing"; PASS=$((PASS + 9))
@InauguralPhysicist
InauguralPhysicist merged commit 8aaf44a into main Jul 28, 2026
19 checks passed
@InauguralPhysicist
InauguralPhysicist deleted the fix-730-json-encode-depth branch July 28, 2026 23:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

json_encode has no depth bound: a cyclic or deeply nested value segfaults, uncatchable (decoder is bounded, encoder is not)

2 participants